Skip to content

Authz refactor w/ orthogonal capabilities#2936

Open
GregorShear wants to merge 4 commits into
masterfrom
greg/authz/1
Open

Authz refactor w/ orthogonal capabilities#2936
GregorShear wants to merge 4 commits into
masterfrom
greg/authz/1

Conversation

@GregorShear
Copy link
Copy Markdown
Contributor

@GregorShear GregorShear commented May 8, 2026

Summary

Adds an orthogonal capability model that coexists with the legacy read/write/admin hierarchy. Existing authorization paths (PostgREST, transitive-roles BFS) are untouched. New GraphQL authorization checks can opt into orthogonal capabilities, and we'll migrate existing GraphQL checks over one at a time. Once the GraphQL API covers everything PostgREST does, PostgREST and the legacy capability path can be retired together.

What changes

A new OrthogonalCapability enum and role_grants.capabilities / user_grants.capabilities columns let a grant carry an independent set of capabilities, rather than a single level in a hierarchy. This is finer-grained than the legacy roles: instead of admin implying everything, a grant lists exactly which capabilities it confers.

Special capabilities:

  • delegate — a grant carrying delegate can propagate its own capabilities to the next hop. The next hop's effective set is node.capabilities ∩ edge.capabilities — you can only pass on capabilities you actually hold. Without delegate, the capabilities apply at the object, but cannot chain further.
  • assume — a grant carrying assume is a trust root: the next hop inherits the full capability set declared on the edge, with no intersection against the parent's caps. Used when delegating complete authority (e.g. a user grant that says "this user fully impersonates tenantA/groups/editors/"), and as the BFS seed marker so user_grants get their declared capabilities through unfiltered.

In short: delegate carries your own permissions forward; assume carries the edge's permissions forward.

Coexistence with legacy

AnyCapability wraps either a single legacy Capability or a Vec<OrthogonalCapability>. RoleGrant::is_authorized and UserGrant::is_authorized dispatch on the variant: the legacy arm runs the existing transitive_roles BFS unchanged; the orthogonal arm runs a new reachable_nodes BFS that respects the delegate / assume rules above. Call sites pick which model they want.

Migration path

The two systems live side-by-side indefinitely. GraphQL authorization checks get migrated to orthogonal capabilities one at a time as we gain confidence. When the GraphQL API has full coverage of what PostgREST does today, PostgREST is retired and the legacy capability column / BFS can be dropped.

Test plan

  • supabase db reset applies cleanly
  • cargo sqlx prepare --workspace is up to date
  • cargo check -p control-plane-api and cargo test -p tables pass
  • New unit tests cover delegate propagation, assume trust-root semantics, terminal nodes, multi-path capability union, and RoleGrant reachability
  • Existing legacy authorization tests still pass

@GregorShear GregorShear requested a review from jshearer May 8, 2026 21:11
Comment thread crates/tables/src/behaviors.rs Fixed
Comment thread crates/tables/src/behaviors.rs Fixed
@GregorShear GregorShear removed the request for review from jshearer May 11, 2026 02:27
@jshearer jshearer added control-plane change:significant This is a significant change labels May 11, 2026
Comment thread crates/tables/src/behaviors.rs Outdated
Comment on lines +103 to +109
if required.is_empty() {
debug_assert!(
false,
"is_authorized called with empty orthogonal capabilities"
);
return false;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asking is_authorized()? with empty capabilities returns false

@GregorShear GregorShear force-pushed the greg/authz/1 branch 7 times, most recently from 9c4d16d to 4ebba92 Compare May 11, 2026 19:55
Comment thread crates/tables/src/behaviors.rs Outdated
Comment on lines +1015 to +1020
let (rg, ug, uid) = build_orthogonal_scenario(
vec![("acmeCo/", vec![Write, Assume])],
vec![("acmeCo/", "bobCo/shared/", vec![Read, Billing, TeamAdmin])],
);
assert_authorized(&rg, &ug, uid, "acmeCo/", vec![Write]);
assert_not_authorized(&rg, &ug, uid, "bobCo/shared/", vec![Write]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note potentially non-obvious behavior

@GregorShear GregorShear marked this pull request as ready for review May 11, 2026 19:57
  Adds an orthogonal capability system alongside the existing hierarchical
  (read/write/admin) authorization model. Both RoleGrant::is_authorized and
  UserGrant::is_authorized now accept `impl Into<AnyCapability>`, dispatching
  to either the legacy BFS (transitive_roles/GrantRef) or the new orthogonal
  BFS (reachable_nodes/NodeRef).
@jshearer jshearer force-pushed the greg/authz/1 branch 2 times, most recently from 7aa86fb to 1d60747 Compare May 20, 2026 22:08
let env = ctx.data::<crate::Envelope>()?;
let claims = env.claims()?;

if capability == models::Capability::None {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I introduced the None capability in order to allow us to represent e.g a Billing-only user grant, but we shouldn't let people actually create invite links with that capability. This is unlikely to ever be an issue, and we're going to be refactoring this API to support bundles soon, but just in case.

@jshearer jshearer force-pushed the greg/authz/1 branch 3 times, most recently from 7fda120 to 4393762 Compare May 21, 2026 03:30
@jshearer
Copy link
Copy Markdown
Contributor

Alright @GregorShear take a look at the most recent commit. A few worthy call-outs:

This leaves all existing authorization checks alone

Every existing authorization call-site stays exactly as it was. evaluate_names_authorization, is_authorized, authorized_prefixes, and friends all continue to receive models::Capability::Read/Write/Admin from their callers, return the same errors, and behave identically against current grants. The only thing that changes underneath is what those calls do internally: each legacy capability now lowers into a CapabilitySet (an EnumSet<authz::Capability> of fine-grained bits) via bits_for_legacy, and the BFS evaluates over bits. So the call site says Capability::Admin and what actually gets checked is the Admin bundle's bit set — but no caller has to know that yet.

This means the commit doesn't migrate any enforcement site to fine-grained bits. That's intentional: a future PR can deliberately design new bits and move specific gates onto them (e.g., CreateInviteLink for invite link operations) without conflating that work with the model introduction here.

Capability::None enables bundles-only grants

We added None = 0 to the legacy Capability enum, mapped onto the renamed none value of the DB grant_capability enum (formerly the x_00 placeholder, which already sorted below read). It exists for one reason: to allow a grant whose authorization comes entirely from the new bundles array column, with no legacy capability at all. A grant like capability: none, bundles: [billing] confers exactly the billing bits and nothing else.

Authorization is multi-path union, not any-path

The previous is_authorized() for legacy capabilities used "any one path satisfies" semantics. With the unified BFS, both legacy and bit-shaped queries go through the same algorithm (multi-path bit union), and bits_for_legacy lowers the legacy input to a bit-shaped question. For current grants (bundles=[]), the two rules return identical answers, so the existing snapshot test passes unchanged.

They diverge once bundle-bearing grants exist. The reason is the algebra: Bundle::Admin is defined as Editor | TeamAdmin | Billing. Once Admin is the union of those bit sets, asking is_authorized(_, Admin) is asking "does the user's accumulated bit set cover Editor's ∪ TeamAdmin's ∪ Billing's bits?" Any-path evaluation answers a different question, "did a single grant happen to carry all of those bits at once," which the model has stopped using as a primitive.

The deeper point: the end state isn't "check that the user has Admin's bits at the call site," it's "the call site checks only the specific bits the operation requires." create_invite_link will eventually ask for CreateInviteLink, not for Admin. Once enforcement is bit-shaped at every gate, Bundle::Admin becomes a storage and UI affordance for how humans grant capabilities in administrative-role-sized chunks, not an evaluation predicate. Multi-path union is the evaluation rule that survives this transition.

Naming

Capability is the right name for the fine-grained bit enum. In Rust we can lean into that and have both enums named Capability, namespaced under their respective modules: models::Capability for the legacy read/write/admin enum, models::authz::Capability for the bits. They never collide at use sites because we can fully qualify them.

Type names in GraphQL are a single flat namespace though, so I ended up keeping the OrthogonalCapability name for the bit enum on the GraphQL side, leaving the legacy Capability GraphQL type unchanged for the deployed UI.

The deployed UI's CreateInviteLink mutation references the legacy Capability GraphQL type by name ($capability: Capability!), so we can't rename it in the schema without coordinated UI work. We can rename it back to just Capability once that mutation is updated and the legacy enum is removed entirely.

Authorization grants confer a set of named bundles (Viewer, Writer, Editor, Admin, Billing, TeamAdmin, Delegate, Assume) stored in a new `bundles` array column. Application code maps each bundle to a set of fine-grained capability bits, and authorization checks operate on those bits:

* The bundle-to-bits mapping is an application-layer concern, so new capability bits can be introduced or recomposed in code without a DB migration.
* A single `reachable_nodes` BFS evaluates authorization over `authz::CapabilitySet`; `Delegate` and `Assume` bits express transitivity explicitly, rather than hard-coding propagation when `capability == Admin`.
* `Capability::None` (renamed from the placeholder `x_00`) supports grants whose authorization comes entirely from the bundles column, so roles like `Billing` can be granted without leaking `read` access through RLS.
* A new `OrthogonalCapability` GraphQL enum and `PrefixRef.capabilities` field expose the fine-grained bits to clients, allowing future feature gates to check specific capabilities rather than coarse legacy levels.
* The legacy `capability` column continues to drive RLS unchanged; `bits_for_legacy` converts existing `read`/`write`/`admin` values to bundle-equivalent bit sets so existing authorization callers compile and behave identically for current grants.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:significant This is a significant change control-plane

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants